Skip to content

[Frontend] Simulate the Triton route's kernel in TOGSim - #306

Merged
YWHyuk merged 6 commits into
feature/triton-codegenfrom
feature/triton-lowering
Jul 27, 2026
Merged

[Frontend] Simulate the Triton route's kernel in TOGSim#306
YWHyuk merged 6 commits into
feature/triton-codegenfrom
feature/triton-lowering

Conversation

@YWHyuk

@YWHyuk YWHyuk commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #305 (base is feature/triton-codegen, not develop — review that first). Pairs with PSAL-POSTECH/triton-npu#1, which builds the binary the cycle measurement runs.

#305 got a Triton kernel compiled to a RISC-V ELF. This gets it simulated: one torch.compile of x + y now ends with a TOGSim cycle count.

The one structural difference, and why it is small

A Triton kernel describes a single program instance — the tile loop is not in the kernel, it is the launch grid outside it. PyTorchSim's codegen puts the whole loop nest in the kernel, so the two look incompatible.

They are not. The trace producer already splits at exactly that seam (docs/design/togsim_cpp_trace.md §9.3): togsim_kernel_tile per work-item, enumerated by togsim_kernel, with core assignment owned by togsim_dispatch. A Triton kernel arrives in the shape the outliner otherwise has to produce. What was missing was only the enumeration, and lower_to_emitc.WorkItem supplies it.

What changed

build_tog — four fallbacks, each taken only when the existing condition does not hold, so PyTorchSim's own codegen walks the same path as before:

  • _find_kernel accepts the module's only func.func (the name comes from the Triton kernel, not the fixed kernel)
  • _build roots at top-level loops carrying a role attribute, and treats the whole body as one work-item when there are none. Keying on the attribute matters on its own: bank_vectorize also leaves a bare top-level affine.for, and rooting at that one made every DMA a sibling the traversal never reached
  • the DMA's tensor identity follows memref.reinterpret_cast back to the block argument — triton-shared types pointers as unranked memref<*xf32>, so the operand is a flat view of an argument, never the argument, which left arg_id at −1 and silently broke the address model
  • DMA nodes are recorded in a list so _collect_dma_nodes can seed from them; it only descended from loop nodes, and a DMA outside any loop was dropped before reaching the skeleton

lower_to_emitcWorkItem + _materialize_grid_loop, applied to the trace artifact only. It runs before _rewrite_signature, which erases the kernel arguments and first asserts none are still used; that ordering is what decides where this can live. Everything after is untouched — _parallel_loop_chain finds the tagged loop, the outliner threads the induction variable through iv[], and the loop left behind becomes the dispatch enumeration.

triton_backend/timing.pyemit_trace (post-vcix IR → trace.so + trace_cycles.tsv), run_togsim, and measure_tile_cycles, which chains three pieces that already existed: build_tog sample mode annotates the IR, python -m tnpu.cycle lowers it in tnpu's process, gem5 runs it and CycleSimulator reads one numCycles per marker pair.

Measured

x + y, 1024 elements, XBLOCK 128, grid 8:

TOG        root -> DMA(arg0,load) DMA(arg1,load) Compute(vector) DMA(arg2,store)
trace.cpp  togsim_kernel_tile: offset = iv[0]*128, three togsim_dma + one togsim_compute
           togsim_kernel:      for (p = 0; p < 8; ++p) togsim_dispatch(...)
gem5       tile = 19 cycles
TOGSim     650 cycles

Channel-0 DRAM traffic is 16 reads × 32 B × 16 channels = 8192 B, exactly the 8 work-items × 2 loads × 512 B the kernel should move — so every dispatch in the enumeration really ran.

The MLIR route reports 251 cycles on the same computation. The gap is double buffering: tnpu emits synchronous DMA (is_async=false, no togsim.wait), so load → compute → store serialize inside every work-item and TOGSim has no overlap to model. That is the next gap, and it is named in the README.

What this does not do

Output tensors are not written — marshalling them through Spike is the remaining functional half. The launcher logs that on every call rather than letting an undefined value pass for a computed one, and the test asserts the timing artifacts exist instead of comparing values.

Matmul timing is also still open: build_tog finds compute nodes by the vcix.iv op name and tnpu emits llvm.riscv.sf.vc.* intrinsics, so a GEMM currently yields no compute node.

Regression

tests/ops/elementwise/test_add.py 5/5 and tests/ops/gemm/test_matmul.py 11/11 on the MLIR route; tnpu baselines add err 0, gemm 1.52588e-05. Verified again after rebasing onto the CI commits in #305.

if not os.path.isfile(os.path.join(self.workdir, timing.TRACE_SO)):
timing.emit_trace(self.workdir, self.meta)
result = timing.run_togsim(self.workdir)
logger.info("[triton-npu] %s simulated -> %s", self.kernel_name, result)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace triton-npu to TOGSim

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4005409[Spike]. Same point: this warning is about the functional half, so it should say so.

result = timing.run_togsim(self.workdir)
logger.info("[triton-npu] %s simulated -> %s", self.kernel_name, result)
logger.warning(
"[triton-npu] %s: output tensors are NOT written; the functional "

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

triton-npu -> Spike

@YWHyuk YWHyuk left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structural read is right, and it is the part that could have gone wrong: the trace producer already splits at the work-item seam, so a Triton kernel arrives in the shape the outliner would otherwise have to produce, and only the enumeration was missing. _underlying_block_arg also fixes a genuinely silent bug — arg_id = -1 breaks the address model with no diagnostic.

Four things I would change, in priority order. Two of them are about the same failure mode: a wrong number that looks like a measurement.


1. _build's new root filter is fail-open

roots = [op for op in block.operations
         if op.operation.name == "affine.for" and _has_loop_role(op)]
if roots: ...
# otherwise: the whole body becomes one work-item

Before, any top-level affine.for was a root (an untagged one fell through to _handle_compute). Now an untagged top-level loop is skipped, and if every top-level loop is untagged the kernel takes the no-loop path and yields a wrong graph — where it previously yielded an empty one, which is an obvious failure.

I checked this is latent rather than active: all eight templates (bmm / cat / conv×3 / gemm / maxpool / sdpa / sort) attach a role, and every top-level affine.for in the generated kernels I have locally (5/5) carries one.

Still, the regression it guards against is silent. Suggest: on the no-loop path, raise (or at minimum warn) if the block still contains an untagged top-level affine.for. A few lines, and it turns "cycle counts quietly drifted" into a message.

2. Placeholder cycles are invisible in the result

measure_tile_cycles returning None is logged loudly, but run_togsim's returned dict is indistinguishable from one backed by a real gem5 measurement. The cycle number is the entire output of this path, and a log line scrolls away.

Suggest carrying the fact into the result — a flag in the dict, or a marker in the TSV header that the reader surfaces — so a consumer cannot mistake PLACEHOLDER_CYCLE for a measurement. The comment on that constant already argues exactly this point; this extends it to the value that actually leaves the module.

3. work_item_for encodes the triton-shared ABI as a constant

pid_x = n_tensor + n_scalar + 3          # after gridX, gridY, gridZ

The layout (pointers → user scalars → grid×3 → pid×3) is documented, but if it ever shifts, some other argument silently becomes the program id and the grid loop drives the wrong value. Suggest asserting the shape rather than only computing from it — e.g. check the trailing six arguments are i32 — so a change announces itself.

Same function assumes a 1-D grid. parallel_args scales with len(grid), but kernel_spec._grid only computes the x extent, so a second axis would make the two disagree. assert len(grid) == 1 pins that until y/z actually lands; the comment already notes x is all that is covered today.

5. _is_address_plumbing whitelists by type

The docstring owns the limitation, and the reasoning is sound for this backend — tile data is vector/float, address math is index/integer. The narrower worry is the op-name side: func.return and memref.cast are matched by name and everything else must be arith.*. If the tnpu lowering later emits an llvm.* or index.* op at top level, it is counted as vector compute instead of plumbing.

Given this file raises (_Fatal) elsewhere rather than guessing, an unknown top-level op might be better treated the same way.


Approach and framing both look good to me — stating up front what does not work (output tensors, matmul timing, 650 vs 251 from the missing double buffering) is the right way to land a partial capability. The 8192 B DRAM check is a nice touch: it verifies every dispatch in the enumeration actually ran, which the cycle count alone would not show.

Items 1 and 2 are the ones I would want before merge; 3 and 5 read fine as follow-ups.

@YWHyuk
YWHyuk force-pushed the feature/triton-lowering branch 2 times, most recently from be6802f to 466f517 Compare July 27, 2026 12:54
root and others added 5 commits July 27, 2026 22:09
It prints nothing. The method walks the IR and attaches TOG nodes; `bfs` and
`display` do the printing. The name came from the C++ pass this file ports,
where one method does both -- the docstring now records that so the
correspondence is still findable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Triton kernel describes a single program instance: the tile loop is not in the
kernel, it is the launch grid outside it. The trace producer already wants that
same split -- togsim_kernel_tile per work-item, enumerated by togsim_kernel
(docs/design/togsim_cpp_trace.md sec 9.3) -- so the two models agree and only
the enumeration was missing. This teaches the pipeline to accept a kernel in
that shape, rather than requiring the loop nest PyTorchSim's codegen emits.

build_tog
  _find_kernel falls back to the module's only func.func; the name comes from
  the Triton kernel, not the fixed "kernel".
  _build roots at top-level loops carrying a ROLE attribute, and treats the whole
  body as one work-item when there are none. Keying on the attribute matters on
  its own: bank_vectorize also leaves a bare top-level affine.for for the tile's
  vector work, and rooting at that one made every DMA a sibling the traversal
  never reached.
  A DMA's tensor identity comes from the producer's dram_arg attribute when the
  operand is a view of the argument rather than the argument. Inferring it would
  mean chasing memref view ops back, and there are nine of them with no
  ViewLikeOpInterface in the python bindings to ask generically.
  DMA nodes are recorded in a list so _collect_dma_nodes can seed from them; it
  only descended from loop nodes, and a DMA outside any loop was dropped before
  reaching the skeleton.

lower_to_emitc
  WorkItem + _materialize_grid_loop supply the grid, on the trace artifact only:
  the body is wrapped in a loop per axis, tagged outer_loop, with each
  program-id argument replaced by its induction variable. It must run before
  _rewrite_signature, which erases the arguments and first asserts none are
  still used. Everything after is unchanged -- _parallel_loop_chain finds the
  tagged loop, the outliner threads the induction variable through iv[], and the
  loop left behind becomes the dispatch enumeration.
  Two things about building that nest are easy to get wrong and only show at
  rank >= 2: a nested loop is created before the enclosing yield (an
  InsertionPoint on a block appends, and an scf.for body is already terminated),
  and every bound is created before the first loop, so that a bound made after
  an outer loop does not end up below it while an inner loop uses it.
  A parallel loop may be scf.for as well as affine.for; the role is carried by
  the attribute, not the dialect.
  _strip_aux keeps the kernel the caller resolved instead of matching on a name.

Every change is a fallback: the existing conditions are tested first, so
PyTorchSim's own codegen takes exactly the path it did before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the timing loop: one torch.compile now produces a cycle count. The
launcher emits the trace producer from tnpu's post-vcix IR and hands it to
TOGSim, reusing PyTorchSim's existing trace pipeline unchanged.

timing.py
  emit_trace     04-custom.mlir -> build_skeleton -> trace.so + trace_cycles.tsv
  run_togsim     hand the kernel directory to TOGSimulator.run_standalone
  work_item_for  derives the program-id argument positions from the signature
                 layout (pointers, user scalars, then triton-shared's own
                 gridX,Y,Z / pidX,Y,Z) and the grid from the pinned block sizes

codecache persists meta.json beside the artifacts so the timing step can run
standalone, and TritonNPULauncher.__call__ simulates instead of raising.
kernel_spec._grid becomes grid_of: the timing path needs the same extents to
enumerate work-items, so it is computed in one place.

The test drives a 2-D grid: that it verifies as MLIR, nests one loop per axis,
and dispatches both indices. Checking the module and not only the C++ it becomes
is the point -- the emitc lowering hoists constants to a flat scope, so it hides
a bound that does not dominate its use. Inductor cannot reach this path here (it
uses y/z only when x would overflow), so it gets a test rather than waiting for
a kernel to exercise it.

Measured on Inductor's `x + y` (1024 elements, XBLOCK 128, grid 8): TOGSim
totals 573 cycles, and channel-0 DRAM traffic of 16 reads x 32 B x 16 channels
is 8192 B -- exactly the 8 work-items x 2 loads x 512 B the kernel should move,
so every dispatch in the enumeration really ran. The cycle table is a
placeholder until gem5 sampling lands, and says so on every emit.

Output tensors are NOT written; marshalling them through Spike is the remaining
functional half. The launcher logs that on every call rather than letting an
undefined value pass for a computed one, and the test asserts the timing
artifacts exist instead of comparing values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cycle table held a placeholder, so TOGSim modelled the DMA but charged
nothing for compute. It is now a measurement.

measure_tile_cycles chains three pieces that already existed: build_tog's sample
mode annotates the post-vcix IR (inline-asm markers around each compute node,
every loop rewritten to one trip, so what runs is one tile), `python -m
tnpu.cycle` lowers that to a RISC-V binary in tnpu's own process -- the
Gemmini/VCIX lowering and its LLVM live there -- and CycleSimulator runs it under
gem5, reading one numCycles per marker pair. build_cycle_table then turns the
list into the tsv, keyed by tile_id.

Sampling runs before build_skeleton because both consume the same post-vcix IR
and build_skeleton rewrites it in place.

Failure is not fatal: any step that does not produce a measurement falls back to
the placeholder table and says so, since a kernel that simulates with the wrong
compute cost is more useful than one that will not simulate -- as long as it
announces which it is.

Measured on `x + y` (1024 elements, grid 8): the tile samples at 19 cycles and
TOGSim's total moves 573 -> 650. The MLIR route reports 251 on the same
computation; the remaining gap is double buffering, which tnpu does not emit
yet, so nothing overlaps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The grid machinery handles N axes and is tested to, but grid_of only ever
computed one from xnumel, so work_item_for could only ever build a 1-D
WorkItem -- general plumbing behind a caller that never used it.

grid_of now walks every parallel axis, and fixed_config_for pins a block per
axis. Parallel vs reduction is Inductor's own test (a prefix starting with "r"
is looped inside the kernel, not gridded), and the block name is
f"{prefix.upper()}BLOCK", so neither is guessed.

Two orderings meet here and they are not the same: grid_of returns axes
OUTERMOST first (z, y, x -- x is Inductor's contiguous axis), while
triton-shared always appends the program ids as pidX, pidY, pidZ. work_item_for
therefore builds the argument list per axis instead of as a range; zipping the
two blindly would pair the outermost loop with the wrong id.

Block sizes: the outermost axis gets the lane count, because that is the tile
dimension bank_vectorize spreads over the lanes. The rest get 1, which leaves
the tile exactly the verified [lanes] shape and lets the grid cover everything
else. That is correct but pathological -- an inner block of 1 makes each
work-item move a strided column -- so a multi-axis kernel logs a warning saying
it is not a tiling worth measuring. Choosing real tile sizes is the block-size
policy gap in the README.

Verified: axes/grid/parallel_args come out ['x']/(8,)/[pidX],
['y','x']/(2,1024)/[pidY,pidX] and ['z','y','x']/[pidZ,pidY,pidX]. End-to-end on
a real multi-axis kernel is still untested -- Inductor reaches for y/z only when
x would overflow, which the shapes this route handles do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@YWHyuk
YWHyuk force-pushed the feature/triton-lowering branch from d2a5c9e to c2e004f Compare July 27, 2026 13:11
Only the NUMBER of grid axes has to be compiled in -- how many loops to nest and
how many iv[] slots to fill. The trip counts are just values, and the producer
ABI already carries them: togsim_kernel(ctx, shape_args, n). Baking them in was
what forced a recompile per shape.

A WorkItem extent of None now means "read it from shape_args". The bound cannot
be wired when the loop is built -- shape_args does not exist until
_rewrite_signature adds it -- so the loop takes a placeholder and
_bind_runtime_bounds replaces it once the signature is there. The loops stay in
the entry function (the outliner moves only their bodies), so the read is in
scope where the bound is used.

timing.write_shape computes the grid per launch and writes trace_shape.txt.
The launch already knows the real extents: Inductor appends the numels after the
tensor arguments, so the trailing values are them. Only the PARALLEL numels ride
along -- a reduction axis is looped inside the kernel and never passed, so
counting it would misalign the mapping.

TOGSim reads that sidecar the same way it reads trace_cycles.tsv, from the
kernel directory. main.cc passed nullptr for shape_args; absent file still
means nullptr, so a producer with its bounds baked in is unaffected.

Measured. One trace.so (md5 identical across all three), torch.compile(
dynamic=True), a single kernel directory reused:

  n=1024  grid  8   650 cycles
  n=2048  grid 16  1316 cycles
  n=4096  grid 32  2586 cycles

The functional path still compiles per shape -- tnpu's spec bakes the tensor
extents into the flat memref view -- so this opens the timing half only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@YWHyuk
YWHyuk merged commit 4c71731 into feature/triton-codegen Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant